Skip to content

feat(mcp-server): RFC-0045 step 5, references and repeaters over MCP - #216

Merged
Madraka merged 4 commits into
mainfrom
claude/rfc45-step5
Nov 13, 2026
Merged

Madraka merged 4 commits into
mainfrom
claude/rfc45-step5

Conversation

@Madraka

@Madraka Madraka commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

RFC-0045 rollout step 5: reference and repeater fields over MCP (§6, §9 step 5). Both types stay refused on every production write path until step 7 (fields_unsupported), and the production tool list does not offer them; the shared suites and this PR's tests enable them with SiteGraphStoreOptions.fieldTypes.

What changes

  • list_collections returns fieldsSchema (open), fieldsSchemaStrict and strictCost (from step 1's fieldsJsonSchema / strictSchemaCost). Reference descriptions name the target collections by their labels.
  • create_entry / update_entry:
    • fieldsStrict instead of fields (invalid if both). null or a missing key means "leave as it is" (on create: no value). null sub-values in repeater items are stripped; all-null items are dropped and reported in droppedItems. Read by core-ts's new pure readStrictFields (fields/strict-input.ts).
    • clearFields on update, with either input: set and clear of one key → invalid; unknown key → unknown_field; on create → invalid.
    • A given reference or repeater value replaces the whole value.
  • Prechecks reuse the store's write plan. New NodeWriteOptions.dryRun on createContentEntry/updateContentEntry, and deleteContentEntry(entry, { dryRun }): the store's EntryEngine plans the write and throws every plan-time refusal (values, assets, requiredToPublish, translation links, canonical ids, target guard, where, own group, the 100-references cap, planLeave), then returns without writing. MCP's copy of the field checks (checkEntryFields) is gone; checkEntryWrite calls the dry run.
  • delete_entry refuses in precheck with entry_referenced and the store's details.referrers (published referrers, up to 20, with the total).
  • Approval prompts (field-prompts.ts): "Venue" (venue) → "Main hall" (entry_…, Places), "Speakers" (speakers): 2 items → 3 items (added "Ada Lovelace" (entry_…, People), Speaker), removed items, reorders; titles and text quoted and truncated with quote().
  • New read-only list_references { siteId, entryId, direction: "to" | "from" } (content:read). The API's referrers read moved from api/src/routes/entry-references.ts into core-ts (references/referrers.ts: listReferrers, listReferenceTargets, referenceCard); the API route and the tool both call it. Same site check as the API (another site's entry → not_found), titles marked as content (notice). 110 → 111 tools.
  • list_entries returns groupId. where on a reference field stays refused (invalid_query, tested) until step 8.
  • Translation tools: localized repeater text/rich text by item path (speakers/0/talk) in get_translation_source, folded back by save_translation into a copy of the source's list (core-ts translatedItemValues); bad paths refused in precheck; shared repeaters field_not_localized.
  • Operation tools: sub (rename, retype, remove), collections, maxItems, subFields, itemLabel on change_field_type, onInvalid: "clear" on a continuation, unconvertible in results; headlines name the sub-field.
  • update_collection's input: defsClosure was already in place from step 1. The offered types now follow the store's capability: defineTools({ referenceTypes }), defaultToolList(referenceTypes), and createCloudhalMcpServer passes store.fieldTypes !== undefined. The resolving-$refs test now covers both tool lists. change_field_type's type enum follows the same switch.
  • Docs: node scripts/mcp-tools-doc.ts regenerated packages/mcp-server/README.md. RFC "Amended in 5" added. Changeset rfc45-step5-mcp.md.

Deviations / decisions to check (also in the RFC's "Amended in 5")

  1. The capability gate for tool schemas is by presence of fieldTypes, not supported(siteId): tools are listed before any site is named. The store still decides per site. Production passes none before step 7.
  2. The precheck is the store's dry run, so prechecks now also run the store's translation-link and canonical-id checks (refusals the handler hit anyway). This removes MCP's parallel checker rather than extending it.
  3. "Gated per view as the API is" I read as: the same site scoping as the API route (entry of another site → not_found), content:read, no status gates (an editor's view of the working copy, like the API). If you meant something stricter, tell me.
  4. list_references "from" reads the entry's own entry_refs rows, plus its same-site original's rows it shows by the shared rule (inherited: true), each with the group's members (any status).
  5. An unknown key in fieldsStrict is unknown_field even when null, so a typo is never silently "left as it is". An item that is already {} as given is not a strict drop (the checker's "no empty item" refuses it).
  6. set_field_localized has no sub input over MCP (the store refuses it).

Tests

  • mcp-server/test/references.test.ts (new, 11 cases): list_collections schemas + strictCost; the strict round trip (create with nulls stores nothing; only venue changes leave startsAt/speakers; subsets; item nulls stripped, all-null items dropped and reported; clearFields); every refusal (set-and-clear, unknown keys, clearFields on create, both inputs) before anyone is asked; reference prechecks (other site, wrong collection, missing, repeater item); approval prompts; delete_entry refusal vs a draft referrer; list_references both ways + site scoping + where refusal + groupId; translation item paths; operation inputs (sub rename, retype headline, text → reference with collections, continuation stuck on an archived target, then onInvalid: "clear"); production gate (no fieldTypes: tool lists without the types, update_collection and change_field_type to reference/repeater → fields_unsupported).
  • core-ts: entry-dry-run.test.ts (dry runs refuse and write nothing), fields-strict-input.test.ts, fields-translation.test.ts (repeater paths, 100k-character path).
  • Existing: tool-schemas (both tool lists), tool-order, tools, e2e (111 tools), entry-fields-assets (now through the dry run), fields, translation-tools, collections, listings, API references-api (sqlite + D1).
  • pnpm check green locally on this commit (20/20 and 19/19 turbo tasks, 11m54s).

🤖 Generated with Claude Code

Madraka and others added 2 commits November 13, 2026 03:40
- list_collections: fieldsSchemaStrict and strictCost beside fieldsSchema.
- create_entry/update_entry: fieldsStrict (null leaves a value as it is,
  item nulls stripped, all-null items dropped and reported in droppedItems)
  and clearFields, with the RFC's edge cases.
- Prechecks run the store's own write plan as a dry run
  (NodeWriteOptions.dryRun, deleteContentEntry dryRun): reference targets,
  repeater items and delete_entry's entry_referenced refusal come from the
  store, not a parallel checker.
- Approval prompts name reference targets and repeater item changes.
- New read-only list_references (to/from); the API's referrers read moves
  to core-ts (listReferrers) and both use it. list_entries returns groupId.
- Translation tools: localized repeater text by item path.
- Operation tools: sub, collections, maxItems, subFields, itemLabel, and
  onInvalid "clear" on a continuation.
- update_collection/change_field_type offer the two types only when the
  store has fieldTypes (defineTools({ referenceTypes })).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
1. save_translation folds item paths into the translation's own list
   (else a copy of the source's), keeping its targets, images, selects and
   text not resent; a list of another length is translation_shape_mismatch.
2. Item paths need get_translation_source's sourceRev (conflict when the
   source moved on); the handler refuses paths that no longer fit.
3. Approval prompts describe the dry run's planned entry (canonical ids).
4. During rename_field, removing the new or the old key removes both.
5. A continuation's onInvalid "clear" applies to its report (cleared, no
   refusal); MCP's continuation headline is the stored operation's.
6. The entry dry run returns after the hold, gates, assets and terms
   checks; the prechecks build the same entry the handler writes.
7. list_references clamps offset like the API route.
8. Repeater prompts show an item changed in place as "changed item N".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
@Madraka

Madraka commented Nov 13, 2026

Copy link
Copy Markdown
Contributor Author

Review round 1 fixed in 209770d (all eight findings):

  1. save_translation folds item paths into the translation's own list of the source's length (else a copy of the source's), keeping its targets, images, selects and text not resent; other lengths, and a whole list given with another length, are translation_shape_mismatch (new code, 409 in the API, hint added).
  2. get_translation_source returns sourceRev; save_translation requires it with item paths (invalid without, conflict when the source moved on). The handler refuses paths that no longer fit instead of dropping them.
  3. update_entry's prompt describes the dry run's planned entry: a translation's id reads as its group ("Main hall"), items compare by stored values.
  4. Removing either key during an unfinished rename_field removes both (clearFields and null in fields); the old key is accepted. Done in MCP's patch reading: the store only sees the merged entry, where the removal is already a no-op, and the API and Studio write whole entries.
  5. continueFieldOperation applies the call's onInvalid to its report (cleared, no refusal); MCP's continuation headline comes from the stored operation.
  6. The dry run now returns after the release hold, gates, assets and terms (required_term, scheduled_locked); prechecks build the same entry the handler writes (terms, cover, title, body). What it still does not plan is listed in "Amended in 5".
  7. list_references clamps offset like the API.
  8. An item changed in place reads changed item N: person → "Grace Hopper" (entry_…, People).

Tests: references.test.ts (12 cases: partial re-save, shape mismatch, sourceRev, canonical prompt, changed item, clear during rename, continuation report and headline), core-ts entry-dry-run (required_term) and fields-translation (own list, shape).

Local checks: lint, types, size and docs are clean, and mcp-server, api, cli, astro-renderer and cloudflare tests all pass. In core-ts, every SQLite and DO suite passes; the D1 variants of store.test.ts fail locally only with EADDRNOTAVAIL (ephemeral-port exhaustion, already on main and recorded in the #190 hand-over), with no assertion failures, so CI is the check for D1. Two Studio tests (unchanged here) timed out under full-check load and pass alone.

🤖 Generated with Claude Code

1. A translation's own list is a base for item paths only when it was made
   from the source's current revision; otherwise a partial save is
   translation_shape_mismatch ("send the whole list").
2. A key is an item path only when its first part names a repeater of the
   collection (itemPathRepeater); og/image in an untyped collection stays a
   plain key, as on main.
3. Prompts compare items as canonical JSON, note reorders whenever matched
   items change order, and read the stored list through the markers.
4. During rename_field the old and new key are one field: setting one while
   clearing the other is invalid; null entries describe the planned value.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
@Madraka

Madraka commented Nov 13, 2026

Copy link
Copy Markdown
Contributor Author

Review round 2 fixed in 4ff3347:

  1. A translation's own list is the base for item paths only when existing.translation.source.rev === source.rev. Otherwise (a reorder of the same length included) a partial path save is refused with translation_shape_mismatch, which says to send the whole list. Covered by an MCP test (source reordered, then a partial save) and a pure test.
  2. A key is an item path only when the part before its first / names a repeater of the collection (core-ts itemPathRepeater, used by translatedItemValues, sharedFieldIssues and MCP's sourceRev check). og/image in a collection without definitions saves as on main. Tested both pure and over MCP.
  3. Prompts compare items and sub-values as canonical JSON, add reordered whenever matched items change order, and read the stored list through normalizeThroughMarkers. Tested: permuted keys give no notes; only item 5 changed is shown; a removal plus a swap shows the reorder; an entry not yet rewritten by a running sub-field rename reads as unchanged.
  4. During rename_field the old and new key are one field: setting either while clearing the other is invalid (three cases tested). describeFieldPatch now shows the planned value for any key the write would keep, nulls included.

"Amended in 5" and the changeset are updated. Local checks: types, repo-wide lint and size are clean; the whole mcp-server suite (198) passes, as do core-ts's field tests. The changes are limited to mcp-server and core-ts's pure translation helpers.

🤖 Generated with Claude Code

1. Item paths write only into a copy of the source's list. Once the
   same-locale translation holds its own list for a repeater, paths are
   refused (translation_shape_mismatch, "send the whole list"); the
   rev-based base selection (madeFromCurrent) is gone.
2. Approval prompts read the stored values through the markers with the
   store's own normalizeEntryFields (now exported), so an edition member
   is not treated as a translation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
@Madraka

Madraka commented Nov 13, 2026

Copy link
Copy Markdown
Contributor Author

Review round 3 fixed in c5785bb:

  1. Simplified as asked. Item paths now write only into a copy of the source's list (or into a list sent whole in the same call). Once the same-locale translation holds its own list for that repeater, item paths are refused with translation_shape_mismatch ("send the whole list"). madeFromCurrent and the rev-based base selection are gone. Tests: a first translation by paths works; a re-translation by paths is refused; a whole-list re-translation works; a whole list of the wrong length is refused (pure tests and MCP).
  2. The prompt baseline now uses the store's own normalizeEntryFields, newly exported from @cloudhal/core-ts, so an edition member is not treated as a translation. Tested with a localize marker: the edition member shows "3 items → 3 items"; a same-site translation shows the store's reading, "0 items → 3 items".

"Amended in 5" and the changeset are updated. Local checks: types, repo-wide lint, size and the tool docs are clean; mcp-server's whole suite (199 tests) and core-ts's field tests pass.

🤖 Generated with Claude Code

@Madraka
Madraka merged commit a38b63f into main Nov 13, 2026
8 checks passed
@Madraka
Madraka deleted the claude/rfc45-step5 branch November 13, 2026 02:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant